Задача:
Необходимые для выполнения кода библиотеки: nltk, gensim, scikit-learn, pyldavis.
Взят из части 1 и расширен тем же кодом в 4 раза добавлением еще 3 недель новостей. Дальше будем работать со статьями, где упоминается Google:
import re
import json
with open('hn-corpus-stripped.txt') as fd:
data = json.load(fd)
print('Total texts:', len(data),
'from', min(d['time'] for d in data.values()),
'to', max(d['time'] for d in data.values()))
data = [x['text'] for x in data.values() if re.search(r'\bgoogle\b', x['text'])]
print('Brand-related texts:', len(data))
data = [''.join(c if c in 'abcdefghijklmnopqrstuvwxyz.,' or c.isspace() else ' ' for c in x) for x in data]
Лемматизируем при помощи WordNet (т.к. тексты на английском) и удалим стоп-слова (апострофы строка выше заменяет на пробелы по причинам, описанным в прошлой части). К стоп-словам также отнесем все слова из 1 буквы.
import nltk
import nltk.stem
import nltk.corpus
import nltk.tokenize
nltk.download('punkt')
nltk.download('wordnet')
nltk.download('stopwords')
%%time
lm = nltk.stem.WordNetLemmatizer()
sw = nltk.corpus.stopwords.words('english')
normed = [[lm.lemmatize(w) for w in nltk.word_tokenize(t.replace(',', ' ').replace('.', ' ')) if len(w) > 1 and w not in sw] for t in data]
print('Total words:', sum(len(x) for x in normed))
print('Unique words:', len({w for x in normed for w in x}))
import collections
print('Most frequent words:')
for w, c in collections.Counter(w for x in normed for w in x).most_common(20):
print(c, w)
print('Words occurring in most texts:')
for w, c in collections.Counter(w for x in normed for w in set(x)).most_common(20):
print(c, w)
93293 уникальных слов это слишком много, чтобы быстро применить LDA, поэтому отсечем наиболее частые 10000.
accepted = {w for w, c in collections.Counter(w for x in normed for w in x).most_common(10000)}
normed = [[w for w in t if w in accepted] for t in normed]
Остальные ~80000 слов составляли примерно 10% корпуса:
print('Total words:', sum(len(x) for x in normed))
print('Unique words:', len({w for x in normed for w in x}))
Воспользуемся реализацией LDA с поддержкой многопоточности из библиотеки gensim.
import pickle
import gensim
import gensim.models
import gensim.corpora
import warnings
warnings.filterwarnings("ignore", category=DeprecationWarning)
dictionary = gensim.corpora.Dictionary(normed)
corpus = [dictionary.doc2bow(text) for text in normed]
dictionary.save_as_text('dict')
gensim.corpora.MmCorpus.serialize('corpus', corpus)
for topics, passes in [(20, 20), (60, 10), (100, 10)]:
print('{} topics...'.format(topics))
lda = gensim.models.ldamulticore.LdaMulticore(corpus, id2word=dictionary, num_topics=topics,
chunksize=1000, passes=passes, workers=32)
with open('lda-{}'.format(topics), 'wb') as fd:
pickle.dump(lda, fd)
import os
import pyLDAvis
import pyLDAvis.gensim
dictionary = gensim.corpora.Dictionary.load_from_text('dict')
corpus = gensim.corpora.MmCorpus('corpus')
ldas = {}
for file in os.listdir():
if file.startswith('lda-'):
with open(file, 'rb') as fd:
ldas[int(file[4:])] = pickle.load(fd)
for t, ws in ldas[20].print_topics(num_topics=-1, num_words=10):
print(t, *re.findall('"[^"]+"', ws), sep=' ')
Среди тем просматриваются некоторые осмысленные: например, #4 о языках программирования ("go" в этом контексте это язык от Google), #11 о социальных сетях, #12 о разработке мобильных приложений, #14 о модной нынче технологии blockchain, #16 о самоуправляемых автомобилях (слова "one" и "year" туда попали потому что в целом тема звучит примерно как "Autonomous cars without human drivers are coming to California next year"), или #17 о смартфонах (в том числе на Android). При этом есть и бред: #19, например, похожа на отдельные слова из веб-стандарта — к Google, возможно, какое-то отношение имеет, но тема далеко не очевидна.
for t, ws in ldas[60].print_topics(num_topics=-1, num_words=10):
print(t, *re.findall('"[^"]+"', ws), sep=' ')
Рассматривать модель со 100 темами, кажется, смысла нет.
Визуализация LDAvis состоит из двух частей. Слева отображены все выделенные темы, спроецированные в двумерное пространство с сохранением относительного расстояния между ними. Размер круга показывает насколько сильно тема представлена в корпусе.
При выборе какой-нибудь темы справа отображаются наиболее значимые в ней слова. По умолчанию "значимость" это просто условная вероятность встретить слово в документе на данную тему. Параметр $\lambda$ позволяет добавить влияние частоты слова в корпусе в целом: чем ближе он к нулю, тем более значимыми считаются слова, шанс увидеть которые значительно выше в текстах на выбранную тему, чем в произвольной статье.
pyLDAvis.display(pyLDAvis.gensim.prepare(ldas[20], corpus, dictionary))
Можно заметить, что многие темы довольно сильно пересекаются. Действительно, маловероятно, что в корпусе из статей за месяц содержится много различных текстов про одну компанию. Реальное количество тем скорее всего где-то на уровне 5.
pyLDAvis.display(pyLDAvis.gensim.prepare(ldas[60], corpus, dictionary))
В качестве входа потребуются уже не тексты, а предложения.
lm = nltk.stem.WordNetLemmatizer()
sw = nltk.corpus.stopwords.words('english')
sentences = [[lm.lemmatize(w) for w in nltk.word_tokenize(s.replace(',', ' ')) if len(w) > 1 and w not in sw]
for t in data for s in nltk.tokenize.sent_tokenize(t)]
next(s for s in sentences if len(s) >= 10)
import logging
logging.basicConfig(format='%(asctime)s : %(levelname)s : %(message)s', level=logging.INFO)
model = gensim.models.word2vec.Word2Vec(sentences, workers=32, size=300, min_count=15, window=10, sample=1e-3)
model.init_sims(replace=True)
model.save("word2vec")
import pandas as pd
import numpy as np
import sklearn as sl
import sklearn.cluster
import sklearn.manifold
import sklearn.decomposition
import matplotlib.cm
import matplotlib.pyplot as plt
%matplotlib inline
%config InlineBackend.figure_format = 'retina'
labels = []
tokens = []
for word in model.wv.vocab:
tokens.append(model[word])
labels.append(word)
kmeans = sklearn.cluster.KMeans(n_clusters=30, random_state=0).fit(tokens)
Последуем рекомендации из документации к sklearn.manifold.TSNE и перед его применением уменьшим размерность векторов до 50, оставив только самые значимые главные компоненты.
pca = sklearn.decomposition.PCA(n_components=50)
reduced = pca.fit_transform(tokens)
tsne = sklearn.manifold.TSNE(n_iter=1000, n_components=2, random_state=42)
x, y = tsne.fit_transform(reduced).T
Для каждого кластера нарисуем какое-нибудь близкое к центру слово. Поскольку редкие слова мало что нам скажут (и, скорее всего, являются бредом), будем предпочитать часто встречающиеся. (Стоит заметить, что кластера строились в оригинальном 300-мерном пространстве, поэтому их центры вовсе не обязаны находиться рядом с центрами "пятен" в визуализации.)
import random
frequencies = collections.Counter(w for t in normed for w in t)
selected = [None] * (kmeans.labels_.max() - kmeans.labels_.min() + 1)
for i, w in enumerate(labels):
if w in frequencies:
cluster = kmeans.labels_[i]
d = tokens[i] - kmeans.cluster_centers_[cluster]
d = d.dot(d) / frequencies[w]
if selected[cluster] is None or d < selected[cluster][2]:
selected[cluster] = i, w, d
colors = matplotlib.cm.rainbow(np.linspace(0, 1, len(selected) + 1))
plt.figure(figsize=(10, 10))
plt.scatter(x, y, color=colors[kmeans.labels_], alpha=0.2)
plt.scatter(x[[i for i, w, d in selected]], y[[i for i, w, d in selected]], color='k')
selected = {w for i, w, d in selected}
for i, w in enumerate(labels):
if w in selected:
dy = random.choice((-12, 0, -6))
plt.annotate(w, xy=(x[i], y[i]), xytext=(-4, dy), textcoords='offset points', ha='right', va='bottom', color='black')
plt.show()